Skip to content

Python: feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587) - #7772

Open
Karthik Thota (karthik-0306) wants to merge 7 commits into
microsoft:mainfrom
karthik-0306:fix-issue-7587
Open

Python: feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587)#7772
Karthik Thota (karthik-0306) wants to merge 7 commits into
microsoft:mainfrom
karthik-0306:fix-issue-7587

Conversation

@karthik-0306

@karthik-0306 Karthik Thota (karthik-0306) commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Motivation & Context

Function invocation loops in FunctionInvocationLayer (_tools.py) currently allow capping LLM roundtrips via max_iterations and total function calls via max_function_calls, but lack a wall-clock time limit. Unattended or complex agent runs can execute tools repeatedly and stall for long periods without a bounded total duration.

Additionally, callers currently have no programmatic way to determine why a function invocation run ended (e.g. normal completion vs hitting max_iterations or a tool limit).

This PR addresses #7587 by introducing max_duration_seconds to FunctionInvocationConfiguration and surfacing a _agent_framework_stop_reason signal on ChatResponse.additional_properties.

The issue's motivating scenario is a single, continuous, unattended loop execution. This PR implements that core case, and additionally extends the duration bound to persist across human-approval round-trips (see "Beyond the original ask" below) — an extension we made, not something #7587 explicitly requested.

Description & Review Guide

What are the major changes?

  • max_duration_seconds Config Field: Added max_duration_seconds: float | None to FunctionInvocationConfiguration (TypedDict) and normalized validation (> 0 or None).
  • Graceful Degradation Path: When max_duration_seconds is exceeded mid-loop (checked after each tool batch), further tool calls are disabled (tool_choice = "none") and the model is forced to produce a final text response, reusing the established max_function_calls degradation path.
  • stop_reason Signal: Surface _agent_framework_stop_reason in ChatResponse.additional_properties (and on AgentResponse for Agent.run(stream=True)) with values "completed", "max_iterations", "max_duration_seconds", "max_function_calls", and "max_consecutive_errors".
  • Shared Precedence Logic: A single _apply_batch_limit_decision helper decides the stop reason and tool-disable state for both streaming and non-streaming loops, so the two paths can't independently disagree on precedence (duration → consecutive-errors → call-count).
  • Streaming Finalizer Integration: Wrapped ResponseStream with _finalize_with_stop_reason so streaming callers receive _agent_framework_stop_reason on the final ChatResponse without altering individual streaming update counts.

Beyond the original ask

  • Wall-Clock Budget Tracking Across Approval Round-Trips: budget_state now persists in AgentSession.state (via ToolApprovalMiddleware) so duration is measured cumulatively even when a run pauses for human approval and resumes in a separate agent.run() call. #7587 motivating case has no approval step — this is our own generalization, not an explicit ask. Flagging it as the main thing worth a scoping decision: happy to split it into a follow-up PR if a single-execution-only bound is preferred for this one.
  • Not implemented: the issue also proposes a cumulative input-token bound. We left this out — token count has the same "not a reliable cost proxy" problem the issue raises about call count — and think it deserves separate discussion.

What is the impact of these changes?

  • Provides a wall-clock safeguard against runaway function invocation loops.
  • Enables callers to inspect response.additional_properties["_agent_framework_stop_reason"] to programmatically handle how a run concluded.
  • Backward-compatible: default max_duration_seconds is None (unlimited); approval-persistence logic is a no-op for callers not using ToolApprovalMiddleware.

What do you want reviewers to focus on?

  • Whether the approval-round-trip duration persistence belongs in this PR or should be split out.
  • The _agent_framework_stop_reason design vs. extending FinishReasonLiteral.
  • The _apply_batch_limit_decision precedence order when multiple bounds are hit in the same batch.

Related Issue

Fixes #7587 (core duration bound and stop-reason signal); does not implement the token-count bound also proposed in that issue.

Contribution Checklist

  • The code builds clean without any errors or warnings
  • All unit tests pass, and I have added new tests where possible
  • The PR follows the Contribution Guidelines
  • This PR is linked to an issue and there is no other open PR for this issue (see Related Issue above).
  • This is not a breaking change.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds duration bounds and machine-readable termination reasons to Python function-invocation loops.

Changes:

  • Adds and validates max_duration_seconds.
  • Tracks stop reasons across streaming and non-streaming paths.
  • Adds tests and changelog documentation.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.

File Description
python/packages/core/agent_framework/_tools.py Implements duration tracking and stop reasons.
python/packages/core/tests/core/test_function_invocation_logic.py Tests duration limits and termination signals.
python/CHANGELOG.md Documents the new behavior.
Suppressed comments (2)

python/packages/core/agent_framework/_tools.py:3528

  • The streaming path has the same enforcement gap: approved calls are replayed before this check, while the call-dropping/fallback logic at lines 3449-3466 recognizes only max_function_calls. Consequently, an expired approval or a provider-emitted call despite tool_choice="none" can still execute. Include duration expiry in a shared pre-execution and fallback predicate.
                if (
                    max_duration_seconds is not None
                    and (perf_counter() - budget_state["start_time"]) >= max_duration_seconds
                ):

python/packages/core/agent_framework/_tools.py:3518

  • The streaming branch also leaks the internal action name "stop" as a public stop reason. This is outside the documented value set and differs from approval-time error exhaustion, which reports completed. Use the same documented semantic reason for consecutive-error exhaustion in both paths.
                budget_state.setdefault("stop_reason", "stop")

💡 Add a code-review agent skill for context-aware, tailored reviews. Learn more in the docs.

Comment thread python/packages/core/agent_framework/_tools.py Outdated
Comment thread python/packages/core/agent_framework/_tools.py Outdated
Comment thread python/packages/core/agent_framework/_tools.py Outdated
Comment thread python/packages/core/tests/core/test_function_invocation_logic.py Outdated
Comment thread python/packages/core/agent_framework/_tools.py
@github-actions github-actions Bot changed the title feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587) Python: feat(core): add max_duration_seconds bound and stop_reason signal to tool loop (#7587) Aug 19, 2026
Comment thread python/packages/core/agent_framework/_tools.py
Comment thread python/packages/core/agent_framework/_tools.py Outdated
- **agent-framework-core**: Refactored _apply_batch_limit_decision to compute perf_counter exactly once per decision point, eliminating the structural fragility where the threshold check and log message used separate clock samples.
- **agent-framework-core**: Re-ordered limit checking in Phase 1 to execute before approval response resolution, successfully preventing execution during approved replays when limits are reached. A post-approval check ensures consecutive error limits (�ction == stop) remain handled.
- **agent-framework-core**: Rewrote 6 tests in 	est_function_invocation_logic.py that used a fragile call_count mock. The tests now use a mutable clock array that advances directly during the tool execution semantic step, providing true robustness against internal engine refactors.

Note: The fallback response trigger (_ensure_function_invocation_limit_fallback_response) remains scoped strictly to the function call limit, preserving pre-existing behavior. Expanding this to cover consecutive errors (�ction == stop) or duration timeouts is intentionally left out of scope for this fix.
… streaming limit decision, fix double-counted approval calls against max_function_calls
Comment thread python/CHANGELOG.md
# for streaming we recover it here from the inner ChatResponse stored in the closure.
if inner_chat_responses:
inner = inner_chat_responses[0]
stop_reason = inner.additional_properties.get("_agent_framework_stop_reason")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure about this approach, is there a reason we can't use the finish_reason field, I wouldn't mind adding a custom reason into the existing FinishReasonLiteral (we would have to sync with Roger Barreto (@rogerbarreto) on a name for that and make it consistent between MEAI and here)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason we went with a separate additional_properties key rather than repurposing finish_reason is that they seemed like two different things: finish_reason today reflects why one specific model call ended (from the provider), while this is about why the framework cut the whole multi-turn loop short — a decision we make, not the model. Folding it into finish_reason on the final response would mean losing whatever the model's own real finish_reason was on that last call.

That said, this is your call to make — happy to move this to FinishReasonLiteral if that's what you and Roger land on for consistency with MEAI. Let me know what name(s) you'd like and I'll wire it up accordingly.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we had some internal discussion here, and we don't know what a user would do with this field. Updating finish_reason itself is also not ideal since that would be a breaking change in behavior. But we also do not like adding a lot of stuff into additional properties. So the question then becomes what would be the action the user needs to do that he needs this field? and are there other ways they could achieve the same?

@github-actions

Copy link
Copy Markdown
Contributor

Python Test Coverage

Python Test Coverage Report •
FileStmtsMissCoverMissing
packages/core/agent_framework
   _agents.py4724490%600, 655, 1225, 1270, 1365–1369, 1468, 1498, 1535, 1630, 1658, 1671, 1720, 1722, 1731–1736, 1741, 1743, 1749–1750, 1757, 1759–1760, 1768–1769, 1772–1774, 1784–1789, 1793, 1798, 1800
   _tools.py15739593%232–233, 410, 412, 425, 450–452, 460, 478, 492, 499, 506, 529, 531, 538, 546, 681, 720–722, 730, 785–787, 813, 839, 843, 881–883, 887, 1060, 1072, 1079–1082, 1103, 1111, 1125–1127, 1520, 1605, 1718–1719, 1776, 1823, 1830–1831, 1951, 2028, 2124, 2138, 2141, 2148, 2151, 2157, 2169, 2186, 2195, 2203, 2207, 2227, 2229, 2236, 2294, 2297, 2320, 2327, 2332–2333, 2336, 2340, 2343, 2365, 2399, 2467, 2496–2497, 2594, 2622, 2662, 2665, 2722, 2816, 2928, 3037, 3210, 3213, 3223, 3240–3241, 3763
packages/core/agent_framework/_harness
   _tool_approval.py3654089%69, 72, 77, 114, 131, 134, 137, 194–195, 214, 243, 251, 262, 280–285, 297, 311, 334, 385, 409, 411–412, 444, 454, 469, 471–472, 474–475, 530–532, 584–585, 635, 644
TOTAL48219449390% 

Python Unit Test Overview

Tests Skipped Failures Errors Time
9763 36 💤 0 ❌ 0 🔥 2m 38s ⏱️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Usage: [Issues, PRs], Target: documentation in the code base and learn docs python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: [Feature]: Bound an agent run by duration (and by spend), not only by iteration and call count

4 participants